在 WSL 2 开发环境中,额外挂载一块磁盘是很常见的需求:隔离数据、放缓存,或者模拟更接近真实 Linux 机器的磁盘布局。
这件事手动做也不麻烦,就几条命令,但是由于业务需要自动化所以研究了下,主要麻烦点就是解决这块硬盘是 /dev/sdc 还是 /dev/sdd的问题。这里记录一下当个纪念。
本文所使用的编程语言是 Rust,主要使用 windows、nix 等 crate。主要用于调用 Windows 的虚拟磁盘相关 API 来创建 VHDX 和 Linux 的一些 API。
本文主要处理两类磁盘:
-
虚拟磁盘:基于
.vhdx文件,推荐,也是本文重点 -
物理磁盘:理论上也能接入 WSL,但要先在 Windows 侧卸载该磁盘,流程更麻烦,这里只顺带提一下
整体流程如下:
-
Windows 侧创建 VHDX
-
通过
wsl –mount –vhd … –bare接入 WSL -
Linux 侧识别目标设备
-
创建分区表
-
格式化文件系统
-
挂载到指定目录
#关于识别的核心思路
在 VHDX 接入 WSL 后,在 Linux 里通常会变成 /dev/sdX 一类设备名;但这个名字并不稳定,今天可能是 /dev/sdc,下次可能就成了 /dev/sdd ,总之取决于挂载顺序。所以自动化的关键,不是记住设备名,而是给磁盘一个稳定标识,然后在 Linux 侧用这个标识把它找回来。
本文的做法是:
-
在 Windows 创建 VHDX 时,显式指定一个 GUID
-
在 Linux 侧根据这个 GUID 推导候选设备标识并匹配目标磁盘
#Windows 侧:创建 VHDX
#权限处理
CreateVirtualDisk 是特权操作。普通权限下调用,大概率就是 ERROR_ACCESS_DENIED,所以第一步不是创建磁盘,而是先检查当前进程是否已提权;如果没有,就用 ShellExecuteW(…, "runas", …) 重新拉起自己。
这段逻辑的目标很简单:
-
已经在管理员权限下:继续执行
-
不是管理员:弹 UAC,提权后重启自身,并带上原始参数
代码如下:
use windows::Win32::UI::Shell::ShellExecuteW;
use windows::Win32::UI::WindowsAndMessaging::SW_SHOW;
use windows::core::{PCWSTR, HSTRING};
use std::env;
/// 检查并申请管理员权限
pub fn ensure_admin_privileges() -> bool {
if is_elevated() {
return true;
}
let exe_path = env::current_exe().unwrap();
let exe_path_wide: Vec<u16> = exe_path
.to_string_lossy()
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let args: Vec<String> = env::args().skip(1).collect();
let args_str = args.join(" ");
let args_wide: Vec<u16> = args_str
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let operation = HSTRING::from("runas");
unsafe {
ShellExecuteW(
None,
PCWSTR::from_raw(operation.as_ptr()),
PCWSTR::from_raw(exe_path_wide.as_ptr()),
if args.is_empty() {
PCWSTR::null()
} else {
PCWSTR::from_raw(args_wide.as_ptr())
},
PCWSTR::null(),
SW_SHOW,
);
}
std::process::exit(0);
}
/// 检查当前进程是否已提升权限
fn is_elevated() -> bool {
use windows::Win32::Security::{
GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY,
};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
unsafe {
let mut token = windows::Win32::Foundation::HANDLE::default();
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token).is_ok() {
let mut elevation = TOKEN_ELEVATION::default();
let mut size = 0;
if GetTokenInformation(
token,
TokenElevation,
Some(&mut elevation as *mut _ as *mut _),
std::mem::size_of::<TOKEN_ELEVATION>() as u32,
&mut size,
).is_ok()
{
return elevation.TokenIsElevated != 0;
}
}
}
false
} #创建 VHDX
提权完成后,就可以调用 CreateVirtualDisk 创建 VHDX 了。
这里不要随机生成 GUID。需要显式传入一个 GUID,这个 ID 就是这块磁盘的后面在 WSL 下识别所需要的。
代码如下:
use windows::core::{GUID, PCWSTR};
use windows::Win32::Storage::Vhd::{
CreateVirtualDisk, CREAT_VIRTUAL_DISK_PARAMETERS, CREATE_VIRTUAL_DISK_VERSION_2,
VIRTUAL_DISK_ACCESS_NONE, CREATE_VIRTUAL_DISK_FLAG_FULL_PHYSICAL_ALLOCATION,
VIRTUAL_STORAGE_TYPE, VIRTUAL_STORAGE_TYPE_DEVICE_VHDX, VIRTUAL_STORAGE_TYPE_VENDOR_MICROSOFT,
};
pub fn create_vhdx(path: &str, size_bytes: u64, unique_id: GUID) -> windows::core::Result<()> {
let storage_type = VIRTUAL_STORAGE_TYPE {
DeviceId: VIRTUAL_STORAGE_TYPE_DEVICE_VHDX,
VendorId: VIRTUAL_STORAGE_TYPE_VENDOR_MICROSOFT,
};
let mut params: CREATE_VIRTUAL_DISK_PARAMETERS = unsafe { std::mem::zeroed() };
params.Version = CREATE_VIRTUAL_DISK_VERSION_2;
unsafe {
let v2 = &mut params.Anonymous.Version2;
v2.UniqueId = unique_id;
v2.MaximumSize = size_bytes;
v2.BlockSizeInBytes = 0;
v2.SectorSizeInBytes = 0;
}
let path_wide: Vec<u16> = path.encode_utf16().chain(std::iter::once(0)).collect();
let mut handle = windows::Win32::Foundation::HANDLE::default();
unsafe {
CreateVirtualDisk(
&storage_type,
PCWSTR::from_raw(path_wide.as_ptr()),
VIRTUAL_DISK_ACCESS_NONE,
None,
CREATE_VIRTUAL_DISK_FLAG_FULL_PHYSICAL_ALLOCATION,
0,
¶ms,
None,
&mut handle,
)
}
} 这里偏向使用固定大小预分配的 VHDX。自动扩容的性能可能没有固定大小预分配的好,且在宿主机不断增长容量感觉也很烦。
创建完成后,在 Windows 侧执行执行命令:
wsl --mount --vhd <path> --bare –bare 的意思是“只把这块磁盘接进 WSL,不自动挂载文件系统”。到这里为止,Linux 内核已经能看到这块盘了,但它还没有分区、也没有文件系统,更没有挂载点。下一步要转到 Linux 侧处理。
#Linux 侧:识别正确的设备
#查找设备
VHDX 接进 WSL 后,常见表现是一个新的 /dev/sdX 设备。但这个名字不稳定,不能拿来做自动化。
所以这里需要一个稳定识别策略。
本文的的做法在前面提到过,主要是:在 Windows 侧为 VHDX 指定 GUID,Linux 侧根据这个 GUID 推导出预期 WWID,然后遍历 /sys/block/*/device/wwid 做匹配。
#关于 GUID 到设备标识的匹配
Linux 下块设备的标识,常见可以从 /sys/block/<dev>/device/serial 或 /sys/block/<dev>/device/wwid 读取。这里使用的是 WWID
VHDX 的 GUID 和 WWID 是有关联的,首先 naa.60022480 的前缀是固定的,表示 Microsoft 的 SCSI vendor namespace,然后取前几位反转字节序后再拼接末尾 6 字节就是 WWID 了。
一个完整的 GUID 是 16 字节的:00112233-4455-6677-8899-aabbccddeeff, 而 WWID 总长也是固定的 16 字节,这里前缀用了 4 字节,末尾要用 6 字节,还剩 6 字节,反转一下 Data1 和 Data2
的字节序即反转 00112233-4455 这一部分就是这 6 字节了。
代码如下:
use std::io;
/// 读取 /sys 下设备属性
fn read_sys_attr(dev_name: &str, attr: &str) -> Option<String> {
let path = format!("/sys/block/{}/device/{}", dev_name, attr);
std::fs::read_to_string(&path).ok().map(|s| s.trim().to_string())
}
/// 将 32 位不带分隔符的 GUID(VHDX UniqueId)转换为候选微软 NAA WWID:
/// "naa.60022480" + 一组按当前环境实测可用的字节重排
fn msft_naa_wwid_from_guid_hex(hex: &str) -> Option<String> {
let s = hex.trim();
if s.len() != 32 {
return None;
}
let bytes = (0..32)
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
.collect::<Option<Vec<u8>>>()?;
let mut out = Vec::with_capacity(12);
// Data1 u32(Windows 二进制表示) -> 调整字节顺序
out.extend_from_slice(&[bytes[3], bytes[2], bytes[1], bytes[0]]);
// Data2 u16 -> 调整字节顺序
out.extend_from_slice(&[bytes[5], bytes[4]]);
// 仅取后 6 字节,拼出当前环境下可匹配的 12 字节 body
out.extend_from_slice(&bytes[10..16]);
let body = out.iter().map(|b| format!("{:02x}", b)).collect::<String>();
Some(format!("naa.60022480{}", body))
}
/// 遍历 /sys/block 找盘
pub fn find_device_by_guid(guid_hex: &str) -> std::io::Result<Option<String>> {
let expected = msft_naa_wwid_from_guid_hex(guid_hex).unwrap();
for entry in std::fs::read_dir("/sys/block")? {
let name = entry?.file_name().into_string().unwrap();
let wwid_path = format!("/sys/block/{}/device/wwid", name);
if let Ok(wwid) = std::fs::read_to_string(wwid_path) {
if wwid.trim().eq_ignore_ascii_case(&expected) {
return Ok(Some(format!("/dev/{}", name))); // 找到了!例如 /dev/sdc
}
}
}
Ok(None)
} #创建分区
在通过 WWID 找到磁盘设备后,比如 /dev/sdd,下一步通常是创建 GPT 分区表并加一个分区。
可以通过命令创建分区,但是在最小化 Linux 分发版中,不一定具备分区工具,所以本文采用了 gpt crate 来创建分区, 然后就踩了一个坑:分区表写到磁盘上,并不等于内核立刻知道了它变了。
常见工具如 fdisk、sfdisk 在更新分区表后,会触发内核重新读取分区表。所以这里也需要补这一段, 调用 ioctl(fd, BLKRRPART, …),强制让内核重新扫描分区表:
use std::fs::OpenOptions;
use std::os::unix::io::AsRawFd;
pub fn partition_and_sync(device_path: &str) -> std::io::Result<()> {
let mut disk = gpt::GptConfig::new()
.writable(true)
.create(device_path)?;
disk.add_partition("DATA", 0, gpt::partition_types::LINUX_FS, 0, None)?;
disk.write()?;
let file = OpenOptions::new().read(true).write(true).open(device_path)?;
let fd = file.as_raw_fd();
const BLKRRPART: libc::c_ulong = 0x125F;
let result = unsafe { libc::ioctl(fd, BLKRRPART, 0) };
if result != 0 {
return Err(std::io::Error::last_os_error());
}
// 偷懒写法,更稳妥的做法是轮询等待分区节点出现,而不是盲等固定时间
std::thread::sleep(std::time::Duration::from_millis(500));
Ok(())
} #格式化分区
分区出来后,通常会格式化成 ext4,这里没有找到合适的 crate, 只能用命令了。
在这一步可以配置一下保留块比例。默认情况下,ext 系文件系统通常会给超级用户预留 5% 空间。系统盘上这很合理,但如果这块盘只是你的数据盘或缓存盘,默认值就未必划算了。
本文的采取的策略是:
-
数据盘:可以考虑
-m 1 -
缓存盘:可以考虑
-m 0
这不是硬规则,取决于实际情况要不要多拿一点可用空间。
具体代码如下:
use std::process::Command;
pub fn format_ext4(partition_path: &str, reserved_percent: u8) -> std::io::Result<()> {
let status = Command::new("mke2fs")
.arg("-t").arg("ext4")
.arg("-F")
.arg("-m").arg(reserved_percent.to_string())
.arg(partition_path)
.status()?;
if !status.success() {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
"Format failed",
));
}
Ok(())
} #挂载分区
挂载有一些选项,根据磁盘的用途来配置,对于本文的场景会用的下面的选项:
-
noatime -
nodiratime
原因很简单:对于缓存来说访问时间基本没用,但每次读文件都去更新一次元数据,也有些开销。
代码如下:
use nix::mount::{mount, MsFlags};
pub fn mount_disk(device: &str, target: &str) -> std::io::Result<()> {
std::fs::create_dir_all(target)?;
let flags = MsFlags::MS_NOATIME | MsFlags::MS_NODIRATIME;
mount(
Some(device),
target,
Some("ext4"),
flags,
None::<&str>,
)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
Ok(())
} 如果更在意时间戳语义,也可以研究 lazytime。但对偏开发缓存、构建输出、数据隔离这类用途来说,先把 atime 写入关掉,收益已经很明显了。
#幂等性
为了防止意外被重新格式化,所以每个步骤要考虑幂等性
例如以下情况:
-
有时候 VHDX 已经存在,但没挂进 WSL
-
有时候设备已经接入,但还没分区
-
有时候分区已经在了,但没格式化
-
有时候文件系统没问题,只是没挂载上
如果每一步都带状态检查,就可以在启动时反复执行,而不用担心误伤已有数据。总之先检查,再执行;能跳过就跳。只有这样才能放心挂到自动化流程里。
#小结
这篇的重点其实不是“如何调用某个 API”,而是把整条链路串起来:
-
Windows 侧负责创建并接入磁盘
-
Linux 侧负责识别、分区、格式化、挂载
-
中间靠稳定标识把两边串起来
-
再用幂等性把它变成一个能重复运行的工具
回头看,这里面真正值得记住的点就几个:
-
创建 VHDX 需要管理员权限
-
wsl –mount –vhd –bare只是接入,不是最终挂载 -
/dev/sdX不稳定,自动化必须找唯一标识 -
写完分区表后,要记得通知内核重扫
-
格式化和挂载都值得顺手做一点参数调优
-
能直接复用系统稳定能力的地方,优先复用
剩下的,其实就是把这些点老老实实写进代码里。